package org.hackystat.projecttrajectory.client;

import java.io.StringReader;
import java.io.StringWriter;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.Marshaller;
import javax.xml.bind.Unmarshaller;
import javax.xml.datatype.XMLGregorianCalendar;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import org.hackystat.projecttrajectory.resource.estimate.EstimateResource;
import org.hackystat.projecttrajectory.resource.estimate.jaxb.EstimateIndex;
import org.hackystat.projecttrajectory.resource.estimate.jaxb.EstimateRef;
import org.hackystat.projecttrajectory.resource.estimate.jaxb.ProjectEstimate;
import org.hackystat.projecttrajectory.resource.summary.jaxb.ProjectSummary;
import org.hackystat.projecttrajectory.resource.trajectory.jaxb.ProjectTrajectory;
import org.restlet.Client;
import org.restlet.data.ChallengeResponse;
import org.restlet.data.ChallengeScheme;
import org.restlet.data.MediaType;
import org.restlet.data.Method;
import org.restlet.data.Preference;
import org.restlet.data.Protocol;
import org.restlet.data.Reference;
import org.restlet.data.Request;
import org.restlet.data.Response;
import org.restlet.data.Status;
import org.restlet.resource.Representation;
import org.w3c.dom.Document;

/**
 * Provides a high-level interface for Clients wishing to communicate with a
 * ProjectTrajectory Server.
 * 
 * @author Cam Moore
 * 
 */
public class ProjectTrajectoryClient {
  /** Holds the userEmail to be associated with this client. */
  private String userEmail;
  /** Holds the password to be associated with this client. */
  private String password;
  /**
   * The ProjectTrajectory host, such as
   * "http://localhost:9878/projecttrajectory".
   */
  private String projectTrajectoryHost;
  /** The Restlet Client instance used to communicate with the server. */
  private Client client;
  /** Summary JAXBContext */
  private JAXBContext summaryJAXB;
  /** Trajectory JAXBContext */
  private JAXBContext trajectoryJAXB;
  /** Estimate JAXBContext */
  private JAXBContext estimateJAXB;
  /** The http authentication approach. */
  private ChallengeScheme scheme = ChallengeScheme.HTTP_BASIC;
  /** The preferred representation type. */
  private Preference<MediaType> xmlMedia = new Preference<MediaType>(MediaType.TEXT_XML);
  /** To facilitate debugging of problems using this system. */
  private boolean isTraceEnabled = true;
  
  private static final String estimateStr = "estimate/";

  /**
   * Initializes a new ProjectTrajectoryClient, given the host, userEmail, and
   * password. Note that the userEmail and password refer to the underlying
   * SensorBase and DailyProjectData client associated with this service. This
   * service does not keep its own independent set of userEmails and passwords.
   * Authentication is not actually performed in this constructor. Use the
   * authenticate() method to explicitly check the authentication credentials.
   * 
   * @param host The host, such as 'http://localhost:9878/projecttrajectory'.
   * @param email The user's email used for authentication.
   * @param password The password used for authentication.
   */
  public ProjectTrajectoryClient(String host, String email, String password) {
    validateArg(host);
    validateArg(email);
    validateArg(password);
    this.userEmail = email;
    this.password = password;
    this.projectTrajectoryHost = host;
    if (!this.projectTrajectoryHost.endsWith("/")) {
      this.projectTrajectoryHost = this.projectTrajectoryHost + "/";
    }
    if (this.isTraceEnabled) {
      System.out.println("ProjectTrajectoryClient Tracing: INITIALIZE " + "host='" + host
          + "', email='" + email + "', password='" + password + "'");
    }
    this.client = new Client(Protocol.HTTP);
    try {
      this.summaryJAXB = JAXBContext
          .newInstance(org.hackystat.projecttrajectory.resource.summary.jaxb.ObjectFactory.class);
      this.trajectoryJAXB = JAXBContext.newInstance(
          org.hackystat.projecttrajectory.resource.trajectory.jaxb.ObjectFactory.class);
      this.estimateJAXB = JAXBContext
          .newInstance(org.hackystat.projecttrajectory.resource.estimate.jaxb.ObjectFactory.class);
    } catch (Exception e) {
      throw new RuntimeException("Couldn't create JAXB context instances.", e);
    }

  }

  /**
   * Throws an unchecked illegal argument exception if the arg is null or empty.
   * 
   * @param arg The String that must be non-null and non-empty.
   */
  private void validateArg(String arg) {
    if ((arg == null) || ("".equals(arg))) {
      throw new IllegalArgumentException(arg + " cannot be null or the empty string.");
    }
  }

  /**
   * Does the housekeeping for making HTTP requests to the ProjectTrajectory by
   * a test or admin user.
   * 
   * @param method The type of Method.
   * @param requestString A string, such as "users". No preceding slash.
   * @param entity The representation to be sent with the request, or null if
   *          not needed.
   * @return The Response instance returned from the server.
   */
  private Response makeRequest(Method method, String requestString, Representation entity) {
    Reference reference = new Reference(this.projectTrajectoryHost + requestString);
    Request request = (entity == null) ? new Request(method, reference) : new Request(method,
        reference, entity);
    request.getClientInfo().getAcceptedMediaTypes().add(xmlMedia);
    ChallengeResponse authentication = new ChallengeResponse(scheme, this.userEmail, this.password);
    request.setChallengeResponse(authentication);
    if (this.isTraceEnabled) {
      System.out.println("ProjectTrajectoryClient Tracing: " + method + " " + reference);
      if (entity != null) {
        try {
          System.out.println(entity.getText());
        } catch (Exception e) {
          System.out.println("  Problems with getText() on entity.");
        }
      }
    }
    Response response = this.client.handle(request);
    if (this.isTraceEnabled) {
      Status status = response.getStatus();
      System.out.println("  => " + status.getCode() + " " + status.getDescription());
    }
    return response;
  }

  /**
   * Takes a String encoding of a ProjectSummary in XML format and converts it.
   * 
   * @param xmlString The XML string representing a ProjectSummary.
   * @return The corresponding ProjectSummary instance.
   * @throws Exception If problems occur during unmarshalling.
   */
  private ProjectSummary makeProjectSummary(String xmlString) throws Exception {
    Unmarshaller unmarshaller = this.summaryJAXB.createUnmarshaller();
    return (ProjectSummary) unmarshaller.unmarshal(new StringReader(xmlString));
  }

  /**
   * Takes a String encoding of a ProjectTrajectory in XML format and converts
   * it.
   * 
   * @param xmlString The XML string representing a ProjectTrajectory.
   * @return The corresponding ProjectTrajectory instance.
   * @throws Exception If problems occur during unmarshalling.
   */
  private ProjectTrajectory makeProjectTrajectory(String xmlString) throws Exception {
    Unmarshaller unmarshaller = this.trajectoryJAXB.createUnmarshaller();
    return (ProjectTrajectory) unmarshaller.unmarshal(new StringReader(xmlString));
  }

  /**
   * Authenticates this user and password with this ProjectTrajectory service,
   * throwing a ProjectTrajectoryException if the user and password associated
   * with this instance are not valid credentials. Note that authentication is
   * performed by checking these credentials with the SensorBase; this service
   * does not keep its own independent set of usernames and passwords.
   * 
   * @return This ProjectTrajectoryClient instance.
   * @throws ProjectTrajectoryException If authentication is not successful.
   */
  public synchronized ProjectTrajectoryClient authenticate() throws ProjectTrajectoryException {
    // Performs authentication by invoking ping with user and password as form
    // params.
    String uri = "ping?user=" + this.userEmail + "&password=" + this.password;
    Response response = makeRequest(Method.GET, uri, null);
    if (!response.getStatus().isSuccess()) {
      throw new ProjectTrajectoryException(response.getStatus());
    }
    String responseString;
    try {
      responseString = response.getEntity().getText();
    } catch (Exception e) {
      throw new ProjectTrajectoryException("Bad response", e);
    }
    if (!"ProjectTrajectory authenticated".equals(responseString)) {
      throw new ProjectTrajectoryException("Authentication failed");
    }

    return this;
  }

  /**
   * Returns a ProjectSummary instance from this server, or throws a
   * ProjectTrajectory exception if problems occurred.
   * 
   * @param user The user that owns the project.
   * @param project The project owned by user.
   * @param timestamp The Timestamp indicating the start of the 24 hour period
   *          for the summary.
   * @return A ProjectSummary instance.
   * @throws ProjectTrajectoryException If the credentials associated with this
   *           instance are not valid, or if the underlying SensorBase service
   *           cannot be reached, or if one or more of the supplied user,
   *           password, or timestamp is not valid.
   */
  public synchronized ProjectSummary getProjectSummary(String user, String project,
      XMLGregorianCalendar timestamp) throws ProjectTrajectoryException {
    Response response = makeRequest(Method.GET,
        "summary/" + user + "/" + project + "/" + timestamp, null);

    if (!response.getStatus().isSuccess()) {
      throw new ProjectTrajectoryException(response.getStatus());
    }
    ProjectSummary summary = null;
    try {
      String xmlData = response.getEntity().getText();
      summary = makeProjectSummary(xmlData);
    } catch (Exception e) {
      throw new ProjectTrajectoryException(response.getStatus(), e);
    }
    return summary;
  }

  /**
   * Returns a ProjectTrajectory instance from this server, or throws a
   * ProjectTrajectory exception if problems occurred.
   * 
   * @param user The user that owns the project.
   * @param project The project owned by user.
   * @param timestamp The Timestamp indicating the start of the 24 hour period
   *          for the summary.
   * @return A ProjectTrajectory instance.
   * @throws ProjectTrajectoryException If the credentials associated with this
   *           instance are not valid, or if the underlying SensorBase service
   *           cannot be reached, or if one or more of the supplied user,
   *           password, or timestamp is not valid.
   */
  public synchronized ProjectTrajectory getProjectTrajectory(String user, String project,
      XMLGregorianCalendar timestamp) throws ProjectTrajectoryException {
    Response response = makeRequest(Method.GET,
        "trajectory/" + user + "/" + project + "/" + timestamp, null);

    if (!response.getStatus().isSuccess()) {
      throw new ProjectTrajectoryException(response.getStatus());
    }
    ProjectTrajectory trajectory = null;
    try {
      String xmlData = response.getEntity().getText();
      trajectory = makeProjectTrajectory(xmlData);
    } catch (Exception e) {
      throw new ProjectTrajectoryException(response.getStatus(), e);
    }
    return trajectory;
  }

  /**
   * Returns the ProjectEstimate for the given EstimateRef.
   * @param ref the EstimateRef.
   * @return The ProjectEstimate pointed to by the EstimateRef.
   * @throws ProjectTrajectoryException if there is a problem.
   */
  public synchronized ProjectEstimate getProjectEstimate(EstimateRef ref) 
    throws ProjectTrajectoryException {
    String user = ref.getEstimator();
    String project = ref.getProject();
    Response response = makeRequest(Method.GET, estimateStr + user + "/" + project, null);
    if (!response.getStatus().isSuccess()) {
      throw new ProjectTrajectoryException(response.getStatus());
    }
    ProjectEstimate estimate = null;
    try {
      String xmlData = response.getEntity().getText();
      estimate = makeProjectEstimate(xmlData);
    } catch (Exception e) {
      throw new ProjectTrajectoryException(response.getStatus(), e);
    }
    return estimate;    
  }
  /**
   * Returns a ProjectSummary instance from this server, or throws a
   * ProjectTrajectory exception if problems occurred.
   * 
   * @param user The user who made the estimate.
   * @param project the project.
   * @return A ProjectEstimate instance.
   * @throws ProjectTrajectoryException If the credentials associatec with this
   *           instance are not valid, or if the underlying services cannot be
   *           reached, or if the supplied user or project are not valid.
   */
  public synchronized ProjectEstimate getProjectEstimate(String user, String project)
      throws ProjectTrajectoryException {
    Response response = makeRequest(Method.GET, estimateStr + user + "/" + project, null);
    if (!response.getStatus().isSuccess()) {
      throw new ProjectTrajectoryException(response.getStatus());
    }
    ProjectEstimate estimate = null;
    try {
      String xmlData = response.getEntity().getText();
      estimate = makeProjectEstimate(xmlData);
    } catch (Exception e) {
      throw new ProjectTrajectoryException(response.getStatus(), e);
    }
    return estimate;
  }

  /**
   * Adds the given estimate to the ProjectTrajectory repository.
   * 
   * @param estimate the estimate.
   * @throws ProjectTrajectoryException if there is an error storing the
   *           estimate.
   */
  public synchronized void putProjectEstimate(ProjectEstimate estimate)
      throws ProjectTrajectoryException {
    try {
      String xmlData = makeProjectEstimate(estimate);
      Representation representation = EstimateResource.getStringRepresentation(xmlData);
      String uri = estimateStr + estimate.getEstimator() + "/" + estimate.getProject();
      Response response = makeRequest(Method.PUT, uri, representation);
      if (!response.getStatus().isSuccess()) {
        throw new ProjectTrajectoryException(response.getStatus());
      }
      // Allow ProjectTrajectoryExceptions to be thrown
    } catch (ProjectTrajectoryException f) {
      throw f;
    } catch (Exception e) { // rethrow other exceptions.
      throw new ProjectTrajectoryException("Could not marshal estimate", e);
    }
  }

  /**
   * Returns the EstimateIndex for all the user's estimates
   * 
   * @param user the user.
   * @return The EstimateIndex for all the user's estimates.
   * @throws ProjectTrajectoryException if there is a problem.
   */
  public synchronized EstimateIndex getEstimateIndex(String user) 
    throws ProjectTrajectoryException {
    Response response = makeRequest(Method.GET, estimateStr + user, null);
    if (!response.getStatus().isSuccess()) {
      throw new ProjectTrajectoryException(response.getStatus());
    }
    EstimateIndex index = null;
    try {
      String xmlData = response.getEntity().getText();
      index = makeEstimateIndex(xmlData);
    } catch (Exception e) {
      throw new ProjectTrajectoryException(response.getStatus(), e);
    }
    return index;
  }

  /**
   * Takes a String encoding of a ProjectEstimate in XML format and converts it.
   * 
   * @param xmlString The XML string representing a ProjectEstimate.
   * @return The corresponding ProjectEstimate instance.
   * @throws Exception If problems occur during unmarshalling.
   */
  private ProjectEstimate makeProjectEstimate(String xmlString) throws Exception {
    // should it return null for null String?
    Unmarshaller unmarshaller = this.estimateJAXB.createUnmarshaller();
    return (ProjectEstimate) unmarshaller.unmarshal(new StringReader(xmlString));
  }

  /**
   * Returns the given ProjectEstimate as an XML String.
   * 
   * @param estimate the ProjectEstimate.
   * @return The XML String representation of the ProjectEstimate.
   * @throws Exception if there is a problem.
   */
  private String makeProjectEstimate(ProjectEstimate estimate) throws Exception {
    Marshaller marshaller = estimateJAXB.createMarshaller();
    DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
    dbf.setNamespaceAware(true);
    DocumentBuilder documentBuilder = dbf.newDocumentBuilder();
    Document doc = documentBuilder.newDocument();
    marshaller.marshal(estimate, doc);
    DOMSource domSource = new DOMSource(doc);
    StringWriter writer = new StringWriter();
    StreamResult result = new StreamResult(writer);
    TransformerFactory tf = TransformerFactory.newInstance();
    Transformer transformer = tf.newTransformer();
    transformer.transform(domSource, result);
    String xmlString = writer.toString();
    // Now remove the processing instruction. This approach seems like a total
    // hack.
    xmlString = xmlString.substring(xmlString.indexOf('>') + 1);
    return xmlString;
  }

  /**
   * Takes a String encoding of a EstimateIndex in XML format and converts it.
   * 
   * @param xmlString The XML string representing a EstimateIndex.
   * @return The corresponding EstimateIndex instance.
   * @throws Exception If problems occur during unmarshalling.
   */
  private EstimateIndex makeEstimateIndex(String xmlString) throws Exception {
    // should it return null for null String?
    Unmarshaller unmarshaller = this.estimateJAXB.createUnmarshaller();
    return (EstimateIndex) unmarshaller.unmarshal(new StringReader(xmlString));
  }
}
